Skip to content

feat(web): Solenta Web switch, per-device tokens with revoke, QR pairing, Tailscale HTTPS (#1512 I2, #245) - #1515

Merged
currentbits merged 5 commits into
mainfrom
coder/1512-i2-web-access-ux-3b384f
Oct 6, 2026
Merged

currentbits merged 5 commits into
mainfrom
coder/1512-i2-web-access-ux-3b384f

Conversation

@currentbits

Copy link
Copy Markdown
Owner

Item I2 of #1512, also #245. It lives in Settings › Connections › Solenta Web.

What changed

  1. On/off switch with the listening URLs, persisted across launches. A separate "Allow devices on my network" switch is the only way the server binds beyond loopback, and it warns that LAN is plain HTTP.
  2. Per-device tokens:
    • "Add device" mints a named token, shown once; only its sha256 is stored, in <userData>/web-access.json at mode 0600.
    • The device list shows last-seen times.
    • Revoke terminates that device's live sockets immediately, and its artifact links fail.
    • The old single token is adopted as "Legacy device". Its plaintext file stays while that row is active (--serve-web prints it and Remote Connections reads it over SSH); revoking Legacy deletes it.
  3. QR pairing (URL plus token), rendered locally with qrcode-generator@2.0.4, lazy-loaded with no network call. It prefers the Tailscale, then LAN, then localhost URL.
  4. Tailscale Serve helper: shown only if tailscale is installed and logged in. It exposes over HTTPS with Stop, warns that this opens it to the whole tailnet, won't overwrite an existing HTTPS serve, and stops when Solenta Web turns off. It never installs Tailscale.

Security (each item tested against a real local server):

  • No non-loopback bind unless the user opts in.
  • authorize() hashes the presented token, then timingSafeEquals it against every stored hash with no early exit.
  • Failed auth is rate-limited: 10 per minute per remote address, shared by the WebSocket and the artifact route, and checked before any comparison.
  • Revoke works mid-session.
  • web:* handlers are desktop-only. I checked the bridge: it calls handlers with { ...ctx, transport: "web" }, so a paired browser can't mint, revoke or toggle.

Known limits

  • Through Tailscale every client shares one remote address, so one device failing auth can briefly lock out the other web clients. The desktop app is unaffected.
  • The Tailscale commands are tested against a fake only; the first real click is the live test.
  • A revoked browser retries quietly instead of showing the token screen (follow-up suggested).

Verified independently, on main merged with this branch:

  • typecheck exits 0 and vite build passes.
  • test:renderer: 2865 pass, 0 fail, 0 cancelled.
  • test:electron: 4306 pass, 0 fail, 0 cancelled.
  • Auth code read: webDevices.js and the webBridge.js limiter and disconnect.

🤖 Generated with Claude Code

Willem and others added 5 commits October 6, 2026 18:02
Named device tokens in web-access.json (0600), shown once, sha256 on
disk, constant-time authorize over every row. The single web-token is
adopted as Legacy device. Adds qrcode-generator for the pairing QR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#1512 I2)

webBridge/webServer take an authorize(token) function; each authed socket
remembers its device so revoke terminates it at once. Failed auth is
rate-limited per address (10/min) across the socket and artifact routes.
webAccess.js owns start/stop (loopback unless network access is on),
listen URLs, and a detect-only Tailscale Serve helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…I2)

main.js owns one webAccess: --serve-web still prints its token (now the
Legacy device row); otherwise the saved Settings switch restores the
server on loopback, or 0.0.0.0 only if network access was turned on.
web:* handlers are desktop only. Declared in ipcChannels, CoderApi,
preload, devCoder and fakeCoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…scale (#1512 I2)

Connections pane: serve switch, network-access switch with plain-HTTP
warning, Add device with a one-time link and a locally rendered QR,
device list with last seen and Revoke, and Expose over Tailscale (HTTPS)
with a tailnet exposure note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T16:31:24.422613Z 91a65d4 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91a65d4f89

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread electron/webDevices.js
Comment on lines +162 to +164
fs.unlinkSync(legacyFile);
} catch {
// already gone

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep legacy revocation durable when token-file deletion fails

If deleting web-token fails for any reason other than absence, such as an ACL or transient Windows file-lock error, this catch still reports a successful revocation after removing the row from web-access.json. On the next launch, createWebDevices() reads the surviving plaintext file and adopts the same credential as a new Legacy device, silently restoring the revoked access; ignore only ENOENT and surface or otherwise persist failures that leave the token file present.

Useful? React with 👍 / 👎.

Comment thread electron/webAccess.js
Comment on lines +236 to +243
if (!enabled) {
// Do not leave a tailnet URL proxying to a closed port.
try {
if (server && (await tailscaleStatus()).serving) await setTailscale(false);
} catch (err) {
log(`solenta-web: tailscale serve off failed: ${errorText(err)}`);
}
await stop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Base last-window lifetime on the live Web server

When the app was launched with --serve-web, this newly reachable desktop action can stop the listener, but main.js still uses the unchanged serveOpts.enabled flag in both shouldBlockWindowClose and window-all-closed. After turning Solenta Web off and closing the final Windows/Linux window, the latter therefore returns and leaves an inaccessible headless Electron process running; the close policy needs to follow webAccess.isRunning() or keep the flag synchronized.

Useful? React with 👍 / 👎.

Comment thread electron/webAccess.js
Comment on lines +244 to +247
} else if (!server || host !== (lan ? ANY : LOOPBACK)) {
try {
await start({ host: lan ? ANY : LOOPBACK, port: server ? server.port : undefined });
} catch (err) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the working listener when a LAN rebind fails

When a running loopback server is switched to LAN mode, start() first closes the existing listener and then attempts the wildcard bind. If another process occupies this port on a different interface, the wildcard bind fails even though the old loopback bind worked; this path throws after leaving server null, while the renderer retains its previous running: true status. Restart the previous binding on failure or avoid dropping it until the replacement listener succeeds.

Useful? React with 👍 / 👎.

@currentbits
currentbits merged commit c1ac59a into main Oct 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant